Skip to content

js-legacy: let callers supply data for accounts created in the same transaction - #1347

Open
0xbl33p wants to merge 1 commit into
solana-program:mainfrom
0xbl33p:fix/transfer-hook-preloaded-accounts
Open

js-legacy: let callers supply data for accounts created in the same transaction#1347
0xbl33p wants to merge 1 commit into
solana-program:mainfrom
0xbl33p:fix/transfer-hook-preloaded-accounts

Conversation

@0xbl33p

@0xbl33p 0xbl33p commented Jul 27, 2026

Copy link
Copy Markdown

Problem

Extra-account-meta resolution reads on-chain account data to derive seeds (Seed::AccountData) and pubkeys (PubkeyData::AccountData). That assumes every referenced account already exists.

It often doesn't. The overwhelmingly common transfer shape on Solana is:

0. ComputeBudget
1. AssociatedTokenAccount::createIdempotent   <- recipient's ATA is created here
2. TokenProgram::transferChecked              <- ...and used here

A hook whose extra accounts are derived from the destination — for example an allowlist or eligibility check on the recipient — cannot be resolved for a first-time recipient. unpackSeedAccountData fetches the destination, gets null, and throws TokenTransferHookAccountDataNotFound, so the transaction is never built.

The transfer itself would have succeeded: by the time the instruction executes, instruction 1 has created the account. Only the client-side resolution fails, because it runs before the transaction does.

The practical effect is that per-recipient transfer hooks are unusable through any client built on this library, unless every recipient's token account is created in advance — which is per (wallet, mint) and doesn't scale.

Change

Add an optional preloadedAccounts?: Map<string, Buffer>, keyed by base-58 address, threaded through:

  • createTransferCheckedWithTransferHookInstruction
  • createTransferCheckedWithFeeAndTransferHookInstruction
  • addExtraAccountMetasForExecute
  • resolveExtraAccountMeta
  • unpackSeeds / unpackPubkeyData

Entries take precedence over Connection.getAccountInfo. A caller that knows the recipient can supply the account's future contents:

const destination = getAssociatedTokenAddressSync(mint, wallet, true, TOKEN_2022_PROGRAM_ID);

// Layout of the SPL token account this transaction is about to create.
const pending = Buffer.alloc(165);
mint.toBuffer().copy(pending, 0);
wallet.toBuffer().copy(pending, 32);

const ix = await createTransferCheckedWithTransferHookInstruction(
    connection, source, mint, destination, owner, amount, decimals, [], 'confirmed',
    TOKEN_2022_PROGRAM_ID,
    new Map([[destination.toBase58(), pending]]),
);

Safety

Supplying incorrect data cannot be used to smuggle in a different account. The on-chain resolver re-derives every address from real account state and rejects a mismatch (spl-tlv-account-resolution, ExtraAccountMetaList::check_account_infos), so a wrong guess fails the transaction rather than bypassing the hook.

The parameter is optional and trailing, so existing behaviour and call sites are unchanged.

Tests

Three unit tests in test/unit/transferHook.test.ts:

  • throws TokenTransferHookAccountDataNotFound when a seed account is absent and nothing is supplied (existing behaviour preserved)
  • resolves to the correct PDA when the caller supplies the pending account's data
  • supplied data takes precedence over an on-chain fetch

lint, format, tsc --noEmit and test:unit (97 passing) are all clean.

Context

Found while building a transfer hook that gates a bonding curve on the recipient holding a particular token. It works on-chain, but no third-party terminal or aggregator can build a buy for a first-time recipient, because they all resolve through this library. Happy to adjust the API shape — a callback instead of a map, or narrowing it to the destination account specifically — if maintainers prefer.

…ransaction

Extra-account-meta resolution reads on-chain account data to derive seeds
(`Seed::AccountData`) and pubkeys (`PubkeyData::AccountData`). That assumes
every referenced account already exists, which is not true when the same
transaction creates one of them first — most commonly the recipient's
associated token account, which is created immediately before the transfer.

In that case `unpackSeedAccountData` fetches the account, gets `null`, and
throws `TokenTransferHookAccountDataNotFound`, so the transaction is never
built. This makes any transfer hook whose extra accounts are derived from the
destination unusable for first-time recipients, even though the transfer would
succeed on-chain: by the time the instruction executes, the account exists.

Add an optional `preloadedAccounts` map, keyed by base-58 address, threaded
through `createTransferCheckedWithTransferHookInstruction`,
`createTransferCheckedWithFeeAndTransferHookInstruction`,
`addExtraAccountMetasForExecute` and `resolveExtraAccountMeta`. Entries take
precedence over `Connection.getAccountInfo`. Callers supply only data that
matches what the account will contain; the on-chain resolver re-derives every
address and rejects a mismatch, so this cannot be used to smuggle in a
different account.

The parameter is optional and trailing, so existing behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant